Skip to content

refactor(timeline): freeze-frame uses shared persistGeneratedMediaAsset path#257

Merged
walterlow merged 2 commits into
stagingfrom
fix/freeze-frame-use-persist-helper
May 21, 2026
Merged

refactor(timeline): freeze-frame uses shared persistGeneratedMediaAsset path#257
walterlow merged 2 commits into
stagingfrom
fix/freeze-frame-use-persist-helper

Conversation

@walterlow
Copy link
Copy Markdown
Owner

@walterlow walterlow commented May 21, 2026

Summary

The hand-rolled persist flow in `insertFreezeFrame` duplicated logic that already lives in `mediaLibraryService.importGeneratedImage` (which wraps `persistGeneratedMediaAsset` at `media-asset-helpers.ts:109`). The hand-rolled version had been patched twice during this audit:

…and still didn't roll back the on-disk record if a later step threw. The shared helper has all of those right by construction:

  • `opfsService.saveFile` (file bytes to OPFS)
  • `saveThumbnail` (thumbnail blob + id)
  • `mediaMetadata.thumbnailId` stamped before `createMedia`
  • `createMedia` (persisted with `thumbnailId`)
  • `writeMediaSource` (background workspace mirror)
  • `associateMediaWithProject`
  • rollback of all of the above on any throw

Switched `insertFreezeFrame` to wrap the canvas blob in a `File` and call `mediaLibraryService.importGeneratedImage`. The returned `MediaMetadata` is the same object we prepend to the store on success.

Also added a rollback hop on the `execute(...) === false` branch: if the inner `_splitItem` returns null (rare race — source clip deleted between validation and execute), call `mediaLibraryService.deleteMedia` to clear the persisted frame so we don't leak an on-disk orphan.

Diff shape

  • -26 net lines
  • 4 imports deleted (`MediaMetadata`, `ThumbnailData`, `opfsService`, `writeMediaSource`, plus the dynamic `@/infrastructure/storage` import)
  • Two patched edge cases now handled by the shared helper

Test plan

  • tsc clean
  • oxlint 0 warnings, 0 errors
  • `npm run test:run -- src/features/timeline src/features/media-library` — 134 files / 804 tests pass
  • Manual: insert a freeze frame, reload the project, confirm the thumbnail appears in the media library (same scenario as PR fix(timeline): persisted freeze-frame media missing thumbnail link #253's manual test)
  • Manual (harder): exercise the rare race by deleting the source clip during freeze-frame extraction and confirm no orphan media remains in the library after retry

Summary by CodeRabbit

  • Bug Fixes
    • Improved freeze-frame persistence to use the shared media import flow, reducing failures when saving extracted frames.
    • Added automatic rollback of associated media on save/split failures to keep timelines consistent.
    • Ensured temporary frame resources/URLs are always released, preventing stale references and leaked resources.

Review Change Stack

The hand-rolled persist flow in insertFreezeFrame duplicated logic that
already lives in mediaLibraryService.importGeneratedImage (which wraps
persistGeneratedMediaAsset). The hand-rolled version had been patched
twice this audit — once for the createMedia-before-thumbnailId ordering
bug, once for the prepend-before-execute orphan — and STILL didn't roll
back the on-disk record if a later step failed. The shared helper has
all of those right by construction:

  - opfsService.saveFile (file bytes to OPFS)
  - saveThumbnail (thumbnail blob + id)
  - mediaMetadata.thumbnailId stamped BEFORE createMedia
  - createMedia (persisted with thumbnailId)
  - writeMediaSource (background workspace mirror)
  - associateMediaWithProject
  - rollback of all of the above on any throw

Switched insertFreezeFrame to wrap the canvas blob in a File and call
mediaLibraryService.importGeneratedImage. The returned MediaMetadata is
the same object we prepend to the store on success.

Also added a rollback hop on the execute(...)===false branch: if the
internal _splitItem returns null (rare race — source clip deleted
between validation and execute), call mediaLibraryService.deleteMedia
to clear the persisted frame so we don't leak an on-disk orphan.

Net: -26 lines, deletes 4 imports (MediaMetadata, ThumbnailData,
opfsService, writeMediaSource and the direct @/infrastructure/storage
imports), inherits proper rollback for free.

Verified: tsc clean, lint 0/0, 134 test files / 804 tests pass.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
freecut Ready Ready Preview, Comment May 21, 2026 1:28pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c108bc4b-ba2f-4d37-a053-991c2a8d19b6

📥 Commits

Reviewing files that changed from the base of the PR and between 1540ce1 and 9b2dc02.

📒 Files selected for processing (1)
  • src/features/timeline/stores/actions/edit/freeze-frame-actions.ts

📝 Walkthrough

Walkthrough

This PR refactors insertFreezeFrame to persist extracted frames via mediaLibraryService.importGeneratedImage(), switches imports to use timeline deps, acquires a blob URL for the new media, and adds rollback + blob URL release on timeline mutation failure.

Changes

Freeze-frame media persistence refactor

Layer / File(s) Summary
Imports and dependency switch
src/features/timeline/stores/actions/edit/freeze-frame-actions.ts
Removed low-level storage/OPFS/thumbnail helpers and imported mediaLibraryService from timeline deps.
Persist extracted frame via mediaLibraryService
src/features/timeline/stores/actions/edit/freeze-frame-actions.ts
Constructs a PNG File from frameBlob, calls mediaLibraryService.importGeneratedImage(...) with frame metadata (dimensions/tags/codec), extracts frameMediaId, and acquires the blob URL via blobUrlManager.acquire.
Rollback and blob URL release on execute failure
src/features/timeline/stores/actions/edit/freeze-frame-actions.ts
On timeline split/execute failure, deletes the persisted frame via mediaLibraryService.deleteMediaFromProject(currentProjectId, frameMediaId) (warns if cleanup fails) and always releases the acquired blob URL with blobUrlManager.release(frameMediaId).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I stitched a single frame into the stream,
A tiny PNG that brightened the dream.
Saved by the library, rolled back when it slips,
Blob URL released like soft carrot chips. 🥕✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main refactoring: freeze-frame persistence now uses the shared persistGeneratedMediaAsset path via mediaLibraryService.importGeneratedImage instead of inline storage logic.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/freeze-frame-use-persist-helper

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 21, 2026

Greptile Summary

Replaces the hand-rolled OPFS/thumbnail/metadata persist flow in insertFreezeFrame with a call to mediaLibraryService.importGeneratedImage, which already handles all of those steps plus automatic rollback. A new cleanup hop on execute() === false calls deleteMedia to remove the orphan asset when the underlying _splitItem fails.

  • The shared helper correctly sequences saveThumbnailcreateMediaassociateMediaWithProject with full rollback, eliminating the two bugs patched in PR fix(timeline): persisted freeze-frame media missing thumbnail link #253.
  • The new !success rollback calls deleteMedia (global) rather than the project-scoped deleteMediaFromProject, and does not release the blobUrlManager entry acquired just before execute(), leaving the frame blob pinned in memory on each occurrence of that failure path.

Confidence Score: 3/5

The happy path is clean and the refactor correctly delegates to the shared helper. The failure branch introduced in this PR is incomplete: it cleans up the on-disk asset but leaves the frame blob pinned in blobUrlManager, so every occurrence of the rare split-failure race leaks memory until the session ends.

The core refactor is sound and removes previously buggy hand-rolled persistence. The new rollback branch in the !success path calls deleteMedia without a matching blobUrlManager.release, meaning the frame Blob stays alive in memory. Since this PR explicitly introduces that rollback branch, the omission is a present defect on the changed path.

src/features/timeline/stores/actions/edit/freeze-frame-actions.ts — specifically the !success rollback block around line 222.

Important Files Changed

Filename Overview
src/features/timeline/stores/actions/edit/freeze-frame-actions.ts Refactors freeze-frame media persistence to use the shared importGeneratedImage helper, eliminating the hand-rolled persist flow and its past bugs. Adds a rollback call on execute() === false, but the rollback branch omits blobUrlManager.release, leaking the frame blob in memory on failure. Also uses deleteMedia (global) instead of the preferred deleteMediaFromProject for cleanup.

Sequence Diagram

sequenceDiagram
    participant FF as insertFreezeFrame
    participant MLS as mediaLibraryService
    participant PGA as persistGeneratedMediaAsset
    participant BUM as blobUrlManager
    participant EX as execute(_splitItem)

    FF->>MLS: importGeneratedImage(frameFile, projectId, opts)
    MLS->>PGA: persistGeneratedMediaAsset(...)
    PGA->>PGA: opfsService.saveFile
    PGA->>PGA: saveThumbnail
    PGA->>PGA: createMedia (with thumbnailId)
    PGA->>PGA: writeMediaSource (fire-and-forget)
    PGA->>PGA: associateMediaWithProject
    PGA-->>MLS: MediaMetadata
    MLS-->>FF: MediaMetadata

    FF->>BUM: acquire(frameMediaId, frameBlob)
    BUM-->>FF: frameBlobUrl

    FF->>EX: execute("INSERT_FREEZE_FRAME", ...)
    alt split succeeds
        EX-->>FF: true
        FF->>FF: prependMediaItem(mediaMetadata)
        FF-->>FF: return true
    else split fails (rare race)
        EX-->>FF: false
        Note over FF,BUM: blobUrlManager.release() missing here
        FF->>MLS: deleteMedia(frameMediaId)
        MLS-->>FF: void
        FF-->>FF: return false
    end
Loading

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/features/timeline/stores/actions/edit/freeze-frame-actions.ts:222-235
Missing `blobUrlManager` release in the `!success` rollback. `blobUrlManager.acquire(frameMediaId, frameBlob)` is called on line 144 before `execute()`. When the split returns `false`, `deleteMedia` properly cleans up the on-disk record, but the blob URL entry (and its underlying `Blob` reference) is never released from `blobUrlManager`. On every occurrence of this failure path the frame bytes stay pinned in memory indefinitely.

```suggestion
    if (!success) {
      // Roll back the persisted media so a failed split (rare — only if the
      // source clip was deleted between validation and execute) doesn't leave
      // an orphan on disk.
      blobUrlManager.release(frameMediaId)
      try {
        await mediaLibraryService.deleteMedia(frameMediaId)
      } catch (cleanupError) {
        getLogger().warn(
          '[insertFreezeFrame] Failed to roll back persisted frame after split failure',
          cleanupError,
        )
      }
      return false
    }
```

### Issue 2 of 2
src/features/timeline/stores/actions/edit/freeze-frame-actions.ts:227
`deleteMedia` is documented as the "delete everywhere / no-project-context" variant. Its JSDoc explicitly says: *"Prefer `deleteMediaFromProject(projectId, mediaId)` when a project context exists: it preserves the media for other projects via reference counting."* Since `currentProjectId` is already in scope here, `deleteMediaFromProject` is the correct call — it cleans up the same single association without violating the API contract.

```suggestion
        await mediaLibraryService.deleteMediaFromProject(currentProjectId, frameMediaId)
```

Reviews (1): Last reviewed commit: "refactor(timeline): freeze-frame uses pe..." | Re-trigger Greptile

Comment on lines 222 to 235
if (!success) {
// Roll back the persisted media so a failed split (rare — only if the
// source clip was deleted between validation and execute) doesn't leave
// an orphan on disk.
try {
await mediaLibraryService.deleteMedia(frameMediaId)
} catch (cleanupError) {
getLogger().warn(
'[insertFreezeFrame] Failed to roll back persisted frame after split failure',
cleanupError,
)
}
return false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing blobUrlManager release in the !success rollback. blobUrlManager.acquire(frameMediaId, frameBlob) is called on line 144 before execute(). When the split returns false, deleteMedia properly cleans up the on-disk record, but the blob URL entry (and its underlying Blob reference) is never released from blobUrlManager. On every occurrence of this failure path the frame bytes stay pinned in memory indefinitely.

Suggested change
if (!success) {
// Roll back the persisted media so a failed split (rare — only if the
// source clip was deleted between validation and execute) doesn't leave
// an orphan on disk.
try {
await mediaLibraryService.deleteMedia(frameMediaId)
} catch (cleanupError) {
getLogger().warn(
'[insertFreezeFrame] Failed to roll back persisted frame after split failure',
cleanupError,
)
}
return false
}
if (!success) {
// Roll back the persisted media so a failed split (rare — only if the
// source clip was deleted between validation and execute) doesn't leave
// an orphan on disk.
blobUrlManager.release(frameMediaId)
try {
await mediaLibraryService.deleteMedia(frameMediaId)
} catch (cleanupError) {
getLogger().warn(
'[insertFreezeFrame] Failed to roll back persisted frame after split failure',
cleanupError,
)
}
return false
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/timeline/stores/actions/edit/freeze-frame-actions.ts
Line: 222-235

Comment:
Missing `blobUrlManager` release in the `!success` rollback. `blobUrlManager.acquire(frameMediaId, frameBlob)` is called on line 144 before `execute()`. When the split returns `false`, `deleteMedia` properly cleans up the on-disk record, but the blob URL entry (and its underlying `Blob` reference) is never released from `blobUrlManager`. On every occurrence of this failure path the frame bytes stay pinned in memory indefinitely.

```suggestion
    if (!success) {
      // Roll back the persisted media so a failed split (rare — only if the
      // source clip was deleted between validation and execute) doesn't leave
      // an orphan on disk.
      blobUrlManager.release(frameMediaId)
      try {
        await mediaLibraryService.deleteMedia(frameMediaId)
      } catch (cleanupError) {
        getLogger().warn(
          '[insertFreezeFrame] Failed to roll back persisted frame after split failure',
          cleanupError,
        )
      }
      return false
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

// source clip was deleted between validation and execute) doesn't leave
// an orphan on disk.
try {
await mediaLibraryService.deleteMedia(frameMediaId)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 deleteMedia is documented as the "delete everywhere / no-project-context" variant. Its JSDoc explicitly says: "Prefer deleteMediaFromProject(projectId, mediaId) when a project context exists: it preserves the media for other projects via reference counting." Since currentProjectId is already in scope here, deleteMediaFromProject is the correct call — it cleans up the same single association without violating the API contract.

Suggested change
await mediaLibraryService.deleteMedia(frameMediaId)
await mediaLibraryService.deleteMediaFromProject(currentProjectId, frameMediaId)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/timeline/stores/actions/edit/freeze-frame-actions.ts
Line: 227

Comment:
`deleteMedia` is documented as the "delete everywhere / no-project-context" variant. Its JSDoc explicitly says: *"Prefer `deleteMediaFromProject(projectId, mediaId)` when a project context exists: it preserves the media for other projects via reference counting."* Since `currentProjectId` is already in scope here, `deleteMediaFromProject` is the correct call — it cleans up the same single association without violating the API contract.

```suggestion
        await mediaLibraryService.deleteMediaFromProject(currentProjectId, frameMediaId)
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/features/timeline/stores/actions/edit/freeze-frame-actions.ts (1)

239-242: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Outer catch does not roll back media persisted earlier in the try block.

If anything between line 143 (importGeneratedImage returned successfully) and the execute flow throws — e.g. blobUrlManager.acquire rejects, execute itself throws rather than returning false, or any of the Zustand mutations inside the callback throws — we land here without invoking deleteMedia on frameMediaId, so the persisted file + thumbnail + project association stay on disk while the timeline is unchanged. frameMediaId is also block-scoped inside the try, so even adding cleanup here would need it hoisted.

🛡️ Proposed fix — hoist the id and mirror the rollback in the catch
-  try {
+  let frameMediaId: string | undefined
+  try {
     // ...existing extraction/persist flow...
-    const frameMediaId = mediaMetadata.id
+    frameMediaId = mediaMetadata.id
     const frameBlobUrl = blobUrlManager.acquire(frameMediaId, frameBlob)
     // ...
   } catch (error) {
     getLogger().error('[insertFreezeFrame] Failed:', error)
+    if (frameMediaId) {
+      try {
+        await mediaLibraryService.deleteMedia(frameMediaId)
+        blobUrlManager.release?.(frameMediaId)
+      } catch (cleanupError) {
+        getLogger().warn(
+          '[insertFreezeFrame] Failed to roll back persisted frame after error',
+          cleanupError,
+        )
+      }
+    }
     return false
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/timeline/stores/actions/edit/freeze-frame-actions.ts` around
lines 239 - 242, Hoist the variable that holds the imported media id (currently
created by importGeneratedImage) out of the try block so it’s available in the
outer catch, and in the catch for insertFreezeFrame mirror the same rollback
used on failure paths: if frameMediaId is set call deleteMedia(frameMediaId)
(and any thumbnail/project cleanup you normally do) before returning false;
ensure you also only attempt rollback when frameMediaId is truthy and handle
errors from deleteMedia safely. Make these changes around the
importGeneratedImage call, blobUrlManager.acquire usage, and the execute(...)
flow so any thrown/rejected errors between import and execute trigger the same
cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/timeline/stores/actions/edit/freeze-frame-actions.ts`:
- Around line 222-235: The rollback path in insertFreezeFrame currently deletes
persisted media via mediaLibraryService.deleteMedia(frameMediaId) but never
releases the object URL acquired earlier with
blobUrlManager.acquire(frameMediaId, frameBlob), leaking the blob URL; update
the rollback to call blobUrlManager.release(frameMediaId) (ideally in a finally
block so it runs even if deleteMedia throws) referencing insertFreezeFrame,
frameMediaId, and blobUrlManager.release to ensure the acquired URL is always
released during failure cleanup.

---

Outside diff comments:
In `@src/features/timeline/stores/actions/edit/freeze-frame-actions.ts`:
- Around line 239-242: Hoist the variable that holds the imported media id
(currently created by importGeneratedImage) out of the try block so it’s
available in the outer catch, and in the catch for insertFreezeFrame mirror the
same rollback used on failure paths: if frameMediaId is set call
deleteMedia(frameMediaId) (and any thumbnail/project cleanup you normally do)
before returning false; ensure you also only attempt rollback when frameMediaId
is truthy and handle errors from deleteMedia safely. Make these changes around
the importGeneratedImage call, blobUrlManager.acquire usage, and the
execute(...) flow so any thrown/rejected errors between import and execute
trigger the same cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 04638b20-63bf-48db-ad74-a6a9ddaae697

📥 Commits

Reviewing files that changed from the base of the PR and between 8220726 and 1540ce1.

📒 Files selected for processing (1)
  • src/features/timeline/stores/actions/edit/freeze-frame-actions.ts

Comment thread src/features/timeline/stores/actions/edit/freeze-frame-actions.ts
Two issues raised by reviewers on the freeze-frame persist refactor:

1. P1 — Blob URL leak on rollback. blobUrlManager.acquire(frameMediaId,
   frameBlob) ran before execute(); when split returned false we called
   deleteMedia but never released the blob URL entry. Each failure
   would accumulate a leaked Blob in memory. Added matching
   blobUrlManager.release(frameMediaId) in the !success branch.

2. P2 — Use deleteMediaFromProject not deleteMedia. The frame was just
   associated with currentProjectId and is only referenced by this
   project, so the reference-counted variant is the right call. The
   docstring for deleteMedia explicitly says to prefer
   deleteMediaFromProject when project context exists.

Verified: tsc clean, lint 0/0, 113 test files / 648 timeline tests pass.
@walterlow walterlow merged commit b37931c into staging May 21, 2026
5 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request May 28, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant